iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 16
3

你所不知道的 JS

本文主要會談到

  • this 是什麼?判斷 this 的值的四個規則與例外。
  • 語彙的 this,這裡會提到箭頭函數中的 this 的不同之處。

this 是什麼?

what is this?

圖片來源:What is this meaning of this?

...

...

this 是函式執行時所屬的物件,其值是在執行時期做綁定,可大致歸納為四個規則以供判斷。

判斷 this 的四個規則

this 的值可用四種規則判斷:預設綁定、隱含綁定、明確綁定和 new 綁定,以下分別述之。

預設綁定(Default Binding)

當其他規則都不適用時,意即不屬於任何物件的方法、沒有使用 bind、call、apply 或 new,就套用預設綁定,此時 this 的值就是預設值全域物件,在瀏覽器環境底下是 window。

範例如下,sayHello 不是某個物件的方法,也沒有使用 bind、call、apply 或 new,因此 this 的值即 window。

function sayHello() {
  console.log(this);
}

sayHello(); // Window

再看下面一個例子...在函式 sayHello 內嘗試印出 this.hello,由於此時 this 的值是 window,因此等同於查找 window.hello 的值,就會找到在全域範疇所定義的 hello 了,最後就會印出「Hello World」。

function sayHello() {
  console.log(this.hello);
}

var hello = 'Hello World';
sayHello(); // Hello World

注意,若在函式內使用 'use strict' 宣告成嚴格模式,則 this 的值會改為 undefined,而非原本預設的全域物件 window。

function sayHello() {
  'use strict';
  console.log(this);
}

sayHello(); // undefined

嚴格模式(Strict Mode)

在上一個例子中,我們看到了由於在函式內使用 'use strict' 宣告成嚴格模式,this 的值改為 undefined,而非原本預設的全域物件 window,那麼,什麼是「嚴格模式」呢?

嚴格模式簡單說就是為了預防開發者的一些不小心或錯誤的行為,JavaScript 引擎協助做了一些檢測的工作,當開發者誤用時就把錯誤丟出來,可參考-MDN

範例如下,在未宣告變數而賦值的狀況下,會無預警的產生一個全域變數,但若使用嚴格模式('use strict')則會禁止這行為外,還會報錯,告知開發者變數尚未被定義。

'use strict';

a = 1; // Uncaught ReferenceError: a is not defined

隱含綁定(Implicit Binding)

當函式為物件的方法(method)時,在執行階段 this 就會被綁定至該物件。

範例如下,函式 prompt 為物件 user 的方法,在執行階段 this 被綁至 user,因此 console 時會到 user 查找其屬性 name。

const user = {
  name: 'Jack',
  sayHi: prompt
}

function prompt() {
  console.log(this.name);
}

user.sayHi(); // 'Jack'

注意,只有最頂層(或說是最終層)的物件才是有用的。如下,prompt 的 this 是最終層的物件 anotherUser。

const anotherUser = {
  name: 'Not Jack!',
  sayHi: prompt
}

const user = {
  name: 'Jack',
  anotherUser: anotherUser
}

function prompt() {
  console.log(this.name);
}

user.anotherUser.sayHi(); // 'Not Jack!'

隱含的失去(Implicitly Lost)

隱含綁定也同時會有「隱含失去」的問題-隱含失去是指函式失去綁定的物件,退回到預設綁定的狀態,意即 this 是全域物件 window 或嚴格模式下的 undefined。

什麼時候會造成隱含的失去呢?

  • 函式只是另一個函式的參考。
  • 參數傳遞中的 callback。
  • DOM element 的事件綁定(event binding)。
Case 1:函式是另一個函式的參考

範例如下,bar 是 obj.foo 的參考,並且 bar 的呼叫地點是在全域範疇,因此會套用預設綁定的規則。

function foo() {
  console.log(this.a);
}

var obj = {
  a: 2,
  foo: foo
};

var bar = obj.foo;
var a = 'oops, global';
bar(); // 'oops, global'

更多資訊可參考下文的間接參考部份。

Case 2:參數傳遞中的 callback

範例如下,doFoo(fn) 的 fn 依舊是 obj.foo 的參考,並且 fn 的呼叫地點是在全域範疇,因此會套用預設綁定的規則。

function foo() {
  console.log(this.a);
}

function doFoo(fn) {
  fn();
}

var obj = {
  a: 2,
  foo: foo
};

var a = 'oops, global';

doFoo(obj.foo); // 'oops, global'
Case 3:DOM element 的事件綁定

範例如下,事件中的 callback 的 this 是觸發事件的元素(DOM element)。

<button id="button">Click Me!</button>

點擊按鈕「Click Me!」 後,console 出目前 this 的值。

var el = document.getElementById('button');

el.addEventListener('click', function(e) {
  console.log(this); // "<button id='button'>Click Me!</button>"
});

關於解法...雖然說 this 有可能會無預警的失去了原先預期綁定的 this 的值,但我們還是可以經由一些方法強制綁定,例如,使用 call、apply、bind,明確指出要綁定給 this 的物件,又或者,可使用軟綁定當 this 退化為全域物件時就給予預設值。

明確綁定(Explicit Binding)

使用 call、apply、bind,明確指出要綁定給 this 的物件。

call

將第一個參數設定為函式內的 context,意即設定第一個參數為函式內 this 的值。與 apply 的差異只在於傳參數的方法 - call 將參數一一傳入,而 apply 將參數使用陣列傳入。

let cat = {
  name: 'Hello Kitty'
};

let dog = {
  name: 'Snoopy'
};

function sayHi(num) {
  console.log('Hello, I am ' + this.name);
  console.log('My number is ' + num);
}

sayHi.call(cat, '1');
sayHi.call(dog, '2');

結果如下。

"Hello, I am Hello Kitty"
"My number is 1"
"Hello, I am Snoopy"
"My number is 2"

apply

let cat = {
  name: 'Hello Kitty'
};

let dog = {
  name: 'Snoopy'
};

function sayHi(args) {
  console.log('Hello, I am ' + this.name);
  console.log('My number is ' + args[0]);
}

sayHi.apply(cat, ['1']);
sayHi.apply(dog, ['2']);

結果如下。

"Hello, I am Hello Kitty"
"My number is 1"
"Hello, I am Snoopy"
"My number is 2"

bind

在執行函式前,綁定要指定的物件,這樣 this 就會是這個物件。

let cat = {
  name: 'Hello Kitty'
};

let dog = {
  name: 'Snoopy'
};

function sayHi() {
  console.log('Hello, I am ' + this.name);
}

sayHi.bind(cat)(); // "Hello, I am Hello Kitty"
sayHi.bind(dog)(); // "Hello, I am Snoopy"

var obj = {
  msg = 'Hi!';
}

setTimeout(function() {
  console.log(this.msg);
}.bind(obj), 2000);
硬綁定(Hard Binding)

硬綁定是指使用 bind 寫死要綁定的物件,可避免函式呼叫時退回到預設綁定。

如下,這裡有一個簡易的綁定 this 的 helper,用來寫死要綁定的物件 obj。

function foo(something) {
  console.log(this.a, something);
  return this.a + something;
}

// 簡易的綁定 this 的 helper
function bind(fn, obj) {
  return function() {
    return fn.apply(obj, arguments);
  };
}

var obj = {
  a: 2
};

var bar = bind(foo, obj);
var b = bar(3); // 2 3
console.log(b); // 5

bind(foo, obj) 中,將 foo 的 this 強制指定為 obj,並將結果指定給 bar,因此當執行 bar(3) 時,this.a 對 obj 查找屬性 a(找到為 2,並非退回到全域範疇)且加上傳入的數字 3,而得到結果 b 為 5。

call、apply、bind 適用情境與方法,整理如下。

bind call apply
適用狀況 函數在執行前先綁定物件做為 context context 較常變動的場景。依照呼叫時的需要帶入不同的物件作為該 function 的 this。在呼叫的當下就立即執行。 與 call 相同
傳參數的方式 代入指定的物件做為 context 參數需要一個一個指定 func.call( context, arg1, arg2, ... ) 參數使用陣列傳入 func.apply( context, [ arg1, arg2, ... ])
API 呼叫的情境(API Call "Contexts")

許多函式庫的函式都會提供一個參數作為綁定的物件,這個參數通常稱為情境參數(context argument),目的是讓開發者不必再使用 bind 來綁定 this 的值。

如下,forEach 傳入兩個參數,分別是要執行的 callback foo 和情境參數 obj,因此 console.log 中的 this 就是 obj。

function foo(el) {
  console.log(el, this.id);
}

var obj = {
  id: 'awesome'
};

[1, 2, 3].forEach(foo, obj); // 1 awesome  2 awesome  3 awesome

new 綁定(new Binding)

this 會指向 new 出來的物件。

function Cat(name) {
  this.name = name;
  this.sayHi = function() {
    console.log('Hi, I am ' + name);
    console.log(this === kitten);
  }
}

var kitten = new Cat('Pusheen'); // "Hi, I am Pusheen"
kitten.sayHi(); // true

總結

如何利用以上的規則決定 this 的值呢?規則套用的優先順序,由高至低排列如下

  • new 綁定
  • 明確綁定
  • 隱含綁定
  • 預設綁定

綁定的例外

雖然以上四個規則看起來很全面、很詳細,但,有規則就有例外。

忽略 this

若在使用 apply、call、bind 這些明確綁定時,傳入 null 或 undefined 作為綁定的物件,則 this 的值會退回到預設綁定的全域物件 window。若不在意 this 到底是什麼,只是要一個佔位值,那麼 null 看起來就是合理的選擇。

如下,可能只是想要攤開一個陣列,或利用 bind 能夠 curry 參數的特性分次傳入參數。

function foo(a, b) {
  console.log(`a: ${a}, b: ${b}`);
}

foo.apply(null, [2, 3]); // a: 2, b: 3

var bar = foo.bind(null, 2);
bar(3); // a: 2, b: 3

備註:關於攤開陣列以作為參數的方法,可使用 ES6 的擴展運算子(spread operator),例如 foo(...[1, 2]) 其效果等同於 foo.apply(null, [1, 2])

function foo(a, b) {
  console.log(`a: ${a}, b: ${b}`);
}

foo(...[1, 2]); // a: 1, b: 2

注意,傳入 null 可能會造成一些難解的 bug。因此,比起 null,傳入「空物件」- 一個完全空的、無委派的物件作為 this 的值是更安全的作法,這樣至少將影響範圍限制在這個空物件上,而不影響全域物件 window,就能避免一些難以察覺和修正的錯誤。

在這裡使用 Object.create(null) 來建立空物件。比起 { } 來說,Object.create(null) 不會與 Object.prototype 有委派關係,所以比 { } 來得更空,稍後會有詳細的篇章來說明原型與行為委派。

function foo(a, b) {
  console.log(`a: ${a}, b: ${b}`);
}

var ø = Object.create(null); // 建立空物件!

foo.apply(ø, [2, 3]); // a: 2, b: 3

var bar = foo.bind(ø, 2);
bar(3); // a: 2, b: 3

間接參考(Indirect Reference)

經由「指定」會產生間接參考,而當函式建立了間接參考時就會套用預設綁定。

例如,p.foo = o.foo 這個指定運算式產生了間接參考,於是套用預設綁定後,this 的值為 window。對照前面提到的四種規則,前三種規則都不符合,當然就只能套用預設綁定了。

function foo() {
  console.log(this.a);
}

var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };

o.foo(); // 3
(p.foo = o.foo)(); // 2

軟綁定(Soft Binding)

當 this 退化成全域物件時,是否可給定預設值?

硬綁定可避免函式呼叫時不小心退回到預設綁定狀態,但也減低了設定 this 的值的彈性。因此,這裡提出一個解法,讓函式能經由隱含綁定或明確綁定的方式設定 this 的值,而當 this 退化成全域物件時,又能給予一個預設值而非全域物件,這種不寫死而動態設定預設值的方式,稱之為「軟綁定」。

建立一個工具來達到軟綁定的目的-若 this 是 global、window 或 undefined 就給予預設值。

if (!Function.prototype.softBind) {
  Function.prototype.softBind = function(obj) {
    var fn = this,
      curried = [].slice.call(arguments, 1),
      bound = function bound() {
        return fn.apply(
          /**
          這裡判斷三種狀況...
            - this 沒有值嗎?例如:undefined
            - this 的值是 window 嗎?
            - this 的值是 global 嗎?

          任一狀況為 true 的話,則就回傳預設要綁定為 this 的物件,也就是 obj
          **/

          (!this ||
            (typeof window !== 'undefined' && this === window) ||
            (typeof global !== 'undefined' && this === global)
          ) ? obj : this,
          curried.concat.apply(curried, arguments)
        );
      };
    bound.prototype = Object.create(fn.prototype);
    return bound;
  };
}

在 Function.prototype 掛一個方法 softBind,這個方法輸入一個物件作為當函式呼叫時不小心退回到預設綁定狀態時的預設綁定物件,輸出是回傳一個函式,之後會依照這個函式當時執行情況的 this 值來決定是否已退回到全域物件而去綁定預設傳入的物件。

function foo() {
  console.log('name: ' + this.name);
}

var obj = { name: 'obj' };
var obj2 = { name: 'obj2' };
var obj3 = { name: 'obj3' };
var fooOBJ = foo.softBind(obj);

fooOBJ(); // (1) name: obj <---- 退回到 obj
obj2.foo = foo.softBind(obj);
obj2.foo(); // (2) name: obj2
fooOBJ.call(obj3); // (3) name: obj3
setTimeout(obj2.foo, 10); // (4) name: obj  <---- 退回到 obj

說明

  • (1) fooOBJ() 的 this 是 window,因此是退回到預設的綁定狀態,在此套用軟綁定工具幫我們設定的 this 的預設值 obj。
  • (2) obj2.foo() 的 this 是 obj2,因此印出「name: obj2」。
  • (3) fooOBJ.call(obj3) 使用 call 明確綁定 this 的值為 obj3,因此印出「name: obj3」。
  • (4) setTimeout(obj2.foo, 10) 中的 obj2.foo 由於參數傳遞中的 callback 會讓函式失去綁定的物件,因此 this 是 window 而退回到預設的綁定狀態,在此套用軟綁定工具幫我們設定的 this 的預設值 obj。

備註,如果對於柯里化(Currying)有點陌生的話,可以點這裡看更多說明。

語彙的 this(Lexical this)

所謂的「語彙的 this」是指 this 的值不適用於以上提到的四種規則來做判斷,而是回歸到語彙範疇的查找,其 this 的值並非源自執行時的綁定,而是定義時包含它的範疇或全域範疇,是無法被覆寫的,這裡即將要提到的用箭頭函數(arrow function) 來綁定 this 的值就是這樣的狀況。

這裡先放一個例子,待稍後解釋。

var name = 'Apple';
var obj = {
  name: 'Jack',
  sayHi: function() {
    console.log(`Hi, I am ${this.name}`);
  }
}

obj.sayHi(); // Hi, I am Jack
setTimeout(obj.sayHi, 1000); // Hi, I am Apple

其中,我們可以看到 obj.sayHi() 印出預期的「Hi, I am Jack」,但 setTimeout(obj.sayHi, 1000); 卻因為前面提過的隱含的失去中的函式是另一個函式的參考而失去了原先 this 的綁定。

先來談一些常用的改變 this 的方法...

  • 透過變數儲存目前 this 的值。
  • 使用 bind、call 或 apply,綁定特定的物件作為 this 的值。

透過變數儲存目前 this 的值...這其實不算是「改變」,只能說是「保留」。如果希望存取到外部的 this,可用一個變數 _this 儲存起來,稍後再用。

修改上例如下,一秒後的確是印出「Hi, I am Jack」。

var obj = {
  name: 'Jack',
  sayHi: function() {
    var _this = this;

    setTimeout(function() {
      console.log(`Hi, I am ${_this.name}`);
    }, 1000);
  }
}

obj.sayHi(); // Hi, I am Jack

當然我們也可以用 bind、call 或 apply 來綁定特定的物件作為 this 的值。關於 bind、call 或 apply 的範例可參考之前提過的明確綁定的部份。

修改上例如下,一秒後的確是印出「Hi, I am Jack」。

var obj = {
  name: 'Jack',
  sayHi: function() {
    setTimeout(function() {
      console.log(`Hi, I am ${this.name}`);
    }.bind(this), 1000);
  }
}

obj.sayHi(); // Hi, I am Jack

既然箭頭函數會自動綁定所在範疇,那也就可以這麼修改了...經由箭頭函數就會把 this 綁在 obj 上。

var obj = {
  name: 'Jack',
  sayHi: function() {
    setTimeout(() => {
      console.log(`Hi, I am ${this.name}`);
    }, 1000);
  }
}

obj.sayHi(); // Hi, I am Jack

但箭頭函數並不是一個所有狀況都適用的超級無敵通用解...

箭頭函數不適用於...

  • 箭頭函數會將 this 強制綁定為執行環境。因此,若不希望 this 被綁定為執行環境,基本上都是不適用的,不需要為了少寫幾個字而硬要使用箭頭函數。
  • 箭頭函數內的 callback 是匿名的,匿名函式的缺點請看這裡

更多關於箭頭函式的 this,可參考這裡

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到...

  • this 是 function 執行時所屬的物件,this 是在執行時期做綁定,其值和函式在哪裡被呼叫有關。
  • 判斷 this 的值的四個規則,並以匹配的優先順序由高至低排列
    • new 綁定:this 會指向 new 出來的物件。
    • 明確綁定:使用 call、apply、bind,明確指出要綁定給 this 的物件。
    • 隱含綁定:當函式為物件的方法時,在執行階段 this 就會被綁定至該物件。
    • 預設綁定:當以上規則都不適用時,就套用預設綁定,在非嚴格模式下,瀏覽器環境 this 的值是預設值全域物件 window,而在嚴格模式下,this 的值是 undefined。
  • 箭頭函數(arrow function)的 this 的值並不適用上面提到的四種規則,this 強制綁定為執行環境,例如在瀏覽器中就是 window。

References

推薦閱讀

推薦閱讀 Kuro 大大關於 this 的好文...


同步發表於部落格


上一篇
你懂 JavaScript 嗎?#15 閉包(Closure)
下一篇
你懂 JavaScript 嗎?#17 物件(Object)
系列文
你懂 JavaScript 嗎?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言